﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.IO;
using System.Reflection;
using System.Threading;

namespace Icedream.InstantFileTransfer
{
    /// <summary>
    /// Represents a client which is able to receive files via IFTP.
    /// </summary>
    public class IFTPClient : IIFTPPluginInterface
    {
        private TcpClient _client = null;
        private TcpListener _passive = null;
        private Version _version = null;
        private NetworkStream _ns = null;
        private StreamReader _sr = null;
        private StreamWriter _sw = null;
        private bool _initialized = false;
        private bool _fileRequested = false;
        private bool _fileInfoRequested = false;
        private bool _fileInfoReceived = false;
        private long _lastTestSize = -1;
        private string _lastFileCode = null;
        private DateTime _testStart = DateTime.Now;
        private TimeSpan _testTime { get { return _testTimeCached = DateTime.Now.Subtract(_testStart); } }
        private TimeSpan _testTimeCached;
        private long _lastTotalBytes = -1;
        private string _lastServer = null;
        private int _lastPort = -1;
        private string outputFile = "";
        private int[] _passivePortRange = { 58000, 58099 };

        /// <summary>
        /// Gets the last test duration time.
        /// </summary>
        public TimeSpan LastTestTime
        { get { return _testTimeCached; } }

        /// <summary>
        /// Gets the last test size in bytes.
        /// </summary>
        public long LastTestSize
        { get { return _lastTestSize; } }

        /// <summary>
        /// Indicates if the server has already accepted the connection
        /// </summary>
        public bool IsInitialized
        {
            get
            {
                if (_ns == null)
                    return false;
                if (_sr == null)
                    return false;
                if (_sw == null)
                    return false;
                return _initialized;
            }
        }

        /// <summary>
        /// Indicates if the IFTP client is connected to a server
        /// </summary>
        public bool IsConnected
        {
            get
            {
                if (_client == null)
                    return false;
                return _client.Connected;
            }
        }

        /// <summary>
        /// Gets or sets which version should be sent to the server. Default is the current library's version.
        /// </summary>
        public Version Version
        {
            get
            {
                return _version;
            }
            set
            {
                _version = value;
            }
        }

        /// <summary>
        /// Gets the port on which passive connections are currently enabled.
        /// If no passive connection is enabled, this is 0.
        /// </summary>
        public int PassivePort
        {
            get {
                if (_passive == null)
                    return 0;
                return ((IPEndPoint)_passive.Server.LocalEndPoint).Port;
            }
        }

        /// <summary>
        /// Gets or sets the begin and the end of a passive port range.
        /// Begin and end port are inclusive.
        /// </summary>
        /// <exception cref="InvalidPortRangeException">Thrown when at least one port of the port range exceeds the range { 1, 0xFFFF }</exception>
        public int[] PassivePortRange
        {
            get { return _passivePortRange; }
            set
            {
                ExceptionOnInvalidPortRange(value);
                _passivePortRange = value;
            }
        }

        #region Protocol functions

        #region Passive connection functions

        /// <summary>
        /// Enables passive connection and notifies the server. This is to be used after initialization or as a response to a PassiveRequest.
        /// </summary>
        /// <exception cref="NoConnectionException">Thrown when disconnected</exception>
        /// <exception cref="UninitializedException">Thrown when connection is not acknowledged by server</exception>
        /// <returns>True on success, otherwise False</returns>
        public bool EnablePassive()
        {
            ExceptionOnUninitialized(); // PASSIVE only to be used after START

            for (int i = _passivePortRange[0]; i <= _passivePortRange[1]; i++)
            {
                try
                {
                    _passive = new TcpListener(IPAddress.Any, i);
                    _passive.Start();

                    Thread passiveListenerThread = new Thread(new ThreadStart(ListenPassive));
                    passiveListenerThread.IsBackground = true;
                    passiveListenerThread.Start();
                }
                catch (SocketException)
                {
                    _passive = null;
                    continue;
                }
            }

            if (_passive != null)
            {
                _sw.WriteLine("PASSIVE " + PassivePort);
                return true;
            }
            else
                return false;
        }

        /// <summary>
        /// Lets the client listen for passive connections.
        /// </summary>
        internal void ListenPassive()
        {
            try
            {
                while (_passive != null)
                {
                    while (_passive.Pending())
                        Thread.Sleep(100);
                    _passive.AcceptTcpClient();

                    Thread passiveConnectionThread = new Thread(new ParameterizedThreadStart(HandlePassive));
                    passiveConnectionThread.IsBackground = true;
                    passiveConnectionThread.Start();
                }
            }
            catch (SocketException)
            {
                return;
            }
            catch (TimeoutException)
            {
                return;
            }
            catch (ObjectDisposedException)
            {
                return; // TcpListener has been disposed
            }
        }

        /// <summary>
        /// Handles a passive connection.
        /// </summary>
        /// <param name="passiveConn">The TcpClient of the passive connection</param>
        internal void HandlePassive(object passiveConn)
        {
            TcpClient tcpConn = (TcpClient)passiveConn;

            NetworkStream _passiveNs = tcpConn.GetStream();
            StreamReader _passiveSr = new StreamReader(_passiveNs);
            StreamWriter _passiveSw = new StreamWriter(_passiveNs);

            int bufferSize = 4096;

            while (tcpConn.Connected)
            {
                IFTPReply r = _passiveSr.ReadReply();

                if (r.Name == "FILE")
                {
                    if (!string.IsNullOrEmpty(_lastFileCode))
                    {
                        if (_lastTotalBytes > -1)
                        {
                            IFTPStreamPlugin streamPlugin = null;

                            foreach (IFTPStreamPlugin p in _streamPlugins)
                            {
                                if (p.CompressionString != null)
                                {
                                    if (p.CanDecompress && r.Arguments[0].ToUpper() == p.CompressionString)
                                    {
                                        streamPlugin = p;
                                        break;
                                    }
                                }
                            }

                            if (streamPlugin == null)
                            {
                                _sw.WriteLine("NACK");
                                tcpConn.Close();
                            }

                            FileStream file = new FileStream(outputFile, FileMode.Create);

                            if (!streamPlugin.Decompress(_ns, file, bufferSize, _lastFileCode, _lastTotalBytes)) // if cancelled
                            {
                                this.Disconnect();
                                this.Connect(_lastServer, _lastPort);
                            }
                        }
                        else
                        {
                            _passiveSw.WriteLine("NACK");
                            tcpConn.Close();
                        }
                    }
                    else
                    {
                        _passiveSw.WriteLine("NACK");
                        tcpConn.Close();
                    }
                }
                else if (r.Name == "BUFFER")
                    int.TryParse(r.Arguments[0], out bufferSize);
                else if (r.Name == "TEST")
                {
                    long size;
                    if (!long.TryParse(r._repArgs[0], out size))
                        _sw.WriteLine("NACK \"Invalid size format\"");
                    else
                        RunDownloadTest(size);
                }
                else
                    // throw new ProtocolViolationException("Invalid passive connection reply");
                {
                    _sw.WriteLine("NACK \"Protocol violation\"");
                    tcpConn.Close();
                }
            }
        }

        /// <summary>
        /// Disables passive connection and notifies the server.
        /// If the client is disconnected, passive connection is disabled automatically.
        /// </summary>
        public void DisablePassive()
        {
            ExceptionOnUninitialized(); // PASSIVE only to be used after START

            if (_passive != null)
            {
                _passive.Stop();
                _passive = null;
                _sw.WriteLine("PASSIVE 0");
            }
        }

        #endregion

        #region Active connection functions

        /// <summary>
        /// Connects to an IFTP server and initializes the connection.
        /// </summary>
        /// <param name="server">The target server</param>
        /// <param name="port">The target port</param>
        public void Connect(string server, int port)
        {
            Connect(server, port, true);
        }

        /// <summary>
        /// Connects to an IFTP server.
        /// </summary>
        /// <param name="server">The target server</param>
        /// <param name="port">The target port</param>
        /// <param name="autoInitialize">Should connection be automatically initialized?</param>
        public void Connect(string server, int port, bool autoInitialize)
        {
            if (server == null)
                throw new ArgumentNullException("server");
            if (port < 1 || port > 0xFFFF)
                throw new ArgumentOutOfRangeException("port");

            IPAddress connectAddress = null;

            if(!IPAddress.TryParse(server, out connectAddress)) // Is server inputted as an IP?
            {
                try
                {
                    IPHostEntry hostEntry = Dns.GetHostEntry(server);
                    if (hostEntry == null)
                        throw new Exception("Server not found in DNS");
                }
                catch (SocketException)
                {
                    throw new Exception("DNS error");
                }
                catch (ArgumentException e)
                {
                    throw e;
                }
            }

            try
            {
                _client = new TcpClient();
                _client.ReceiveTimeout = 5000;
                _client.SendTimeout = 3000;
                _client.Connect(server, port);
            }
            catch (SocketException e)
            {
                throw new ConnectionFailedException("Socket error #" + e.ErrorCode + " - " + e.Message);
            }
            catch (ObjectDisposedException e)
            {
                throw new Exception("Unexpected error", e);
            }

            OnConnected();

            _ns = _client.GetStream();
            _sr = new StreamReader(_ns);
            _sw = new StreamWriter(_ns);

            if (autoInitialize)
                Initialize();
        }

        /// <summary>
        /// Initializes a connection. To be used, if autoInitialize in Connect() is disabled.
        /// </summary>
        public void Initialize()
        {
            ExceptionOnInitialized();

            _sw.AutoFlush = true;
            _sw.WriteLine("START");

            IFTPReply r = _sr.ReadReply();
            while(r.Name != "ACK")
            {
                if (r.Name == "NACK")
                    throw new ConnectionFailedException(r._repArgs.Last());
                else if (r.Name == "VERSION")
                    SendVersion();
                else
                    throw new ProtocolViolationException("Server sent invalid startup reply: " + r.Name);
                r = _sr.ReadReply();
            }

            _initialized = true;
            OnInitialized();
        }

        /// <summary>
        /// Disconnects from current server. You don't need to be connected to use this functions, but in this case it does nothing.
        /// </summary>
        public void Disconnect()
        {
            if (_client != null)
            {
                _client.Close();
                _ns.Dispose();
                _sr = null;
                _sw = null;
                _client = null;
            }
        }

        #endregion

        /// <summary>
        /// Sends the client's version to the server
        /// </summary>
        internal void SendVersion()
        {
            ExceptionOnDisconnected();
            _sw.WriteLine("VERSION " + _version.ToString());
        }

        /// <summary>
        /// Fetches a server's file list.
        /// </summary>
        /// <returns>The file list as IFTPListFile array</returns>
        /// <exception cref="ProtocolViolationException">Thrown when an invalid file size is received</exception>
        public IFTPListFile[] GetFileList()
        {
            return GetFileList(true);
        }

        /// <summary>
        /// Fetches a server's file list.
        /// </summary>
        /// <param name="failOnInvalidSize">Indicates if the client should throw an Exception on invalid size formats. If it is False, the size is set to -1.</param>
        /// <returns>The file list as IFTPListFile array</returns>
        /// <exception cref="ProtocolViolationException">Thrown when an invalid file size is received and failOnInvalidSize is True</exception>
        public IFTPListFile[] GetFileList(bool failOnInvalidSize)
        {
            ExceptionOnUninitialized();

            IFTPReply r;

            List<IFTPListFile> list = new List<IFTPListFile>();
            _sw.WriteLine("LIST");

            if ((r = IFTPReply.ParseLine(_sr.ReadLine())).Name != "LIST")
            {
                while ((r = IFTPReply.ParseLine(_sr.ReadLine())).Name != "EOLIST")
                {
                    long size = -1;
                    if(!long.TryParse(r._repArgs[1], out size) && failOnInvalidSize)
                    {
                        throw new ProtocolViolationException("Server sent invalid list entry");
                    }
                    list.Add(new IFTPListFile(r._repArgs[0], size, r._repArgs[2]));
                }
            }


            return list.ToArray();
        }

        /// <summary>
        /// Tells the server the time which the last test needed.
        /// </summary>
        internal void SendTestTime()
        {
            ExceptionOnUninitialized();
            _sw.WriteLine("TESTTIME " + _testTime.TotalSeconds);
        }

        /// <summary>
        /// Requests the server to give access to a file.
        /// </summary>
        /// <param name="file">The file to request</param>
        /// <exception cref="FileRequestFailedException">Thrown if server denies access to file</exception>
        public void RequestFileAccess(IFTPListFile file)
        {
            RequestFileAccess(file._code);
        }

        /// <summary>
        /// Requests the server to give access to a file by using its download code.
        /// </summary>
        /// <param name="code">The code to access the file</param>
        /// <exception cref="FileRequestFailedException">Thrown if server denies access to file</exception>
        public void RequestFileAccess(string code)
        {
            ExceptionOnUninitialized();

            _lastFileCode = code;

            IFTPAckReply r;

            _sw.WriteLine("CODE " + code);
            if (!(r = IFTPAckReply.ParseLine(_sr.ReadLine())).IsAcknowledged)
                throw new FileRequestFailedException(r.Reason);

            _fileRequested = true;
            _fileInfoReceived = false;
        }

        /// <summary>
        /// Only requests information about a requested file.
        /// </summary>
        public void RequestFileInfo()
        {
            ExceptionOnMissingFileRequest();

            _fileInfoRequested = true;

            IFTPReply r;

            _sw.WriteLine("INFO");
            if ((r = _sr.ReadReply()).Name != "INFO")
                throw new ProtocolViolationException("Server sent bad reply to INFO: " + r.Line);
            r = null;
        }

        /// <summary>
        /// Reads a file header
        /// </summary>
        /// <returns>The file header as an IFTPFileHeaderInstance</returns>
        /// <example>
        /// IFTPFileHeader header;
        /// 
        /// while (!(header = client.ReadFileHeader()).IsEnd)
        /// {
        ///     Console.WriteLine("Header \"" + header.Name + "\" is \"" + header.Value + "\".");
        /// }
        /// </example>
        public IFTPFileHeader ReadFileHeader()
        {
            ExceptionOnMissingFileInfoRequest();

            IFTPFileHeader h = _sr.ReadHeader();

           

            if (h.IsEnd)
            {
                _fileInfoReceived = true;
                _fileInfoRequested = false;
                return h;
            }

            if (h._headerName == "size")
                long.TryParse(h.Value, out _lastTotalBytes);
            if (h._headerName == "name")
                outputFile = h._headerValue;
            return h;
        }

        /// <summary>
        /// Requests information about a requested file and returns them all at once.
        /// </summary>
        /// <exception cref="FileRequestMissingException">Thrown if no file request succeeded before calling GetFileInfo</exception>
        public Dictionary<string, string> GetFileInfo()
        {
            RequestFileInfo();

            Dictionary<string, string> headers = new Dictionary<string, string>();
            IFTPFileHeader h;

            while (!(h = ReadFileHeader()).IsEnd)
            {
                headers[h._headerName] += h._headerValue;
            }

            h = null;

            return headers;
        }

        /// <summary>
        /// Starts a synchronous file transfer to download the requested file. In GUIs, this is to be used in a background worker or in any background thread.
        /// </summary>
        public void GetFile()
        {
            ExceptionOnUninitialized();
            ExceptionOnMissingFileRequest();
            ExceptionOnInvalidOutputFileName();

            bool direct = true;

            _sw.WriteLine("ACK"); // Accept file offer

            nextOffer:
            IFTPReply r = _sr.ReadReply();
            int bufferSize = 4096;
            while(r.Name != "FILE")
            {
                if (r.Name == "NACK")
                    if (direct)
                        throw new NotAcknowledgedException();
                    else
                        throw new MissingCompressionPluginException();
                else if (r.Name == "BUFFER")
                    int.TryParse(r.Arguments[0], out bufferSize);
                else if (r.Name == "TEST")
                {
                    long size;
                    if (!long.TryParse(r._repArgs[0], out size))
                        _sw.WriteLine("NACK \"Invalid size format\"");
                    else
                        RunDownloadTest(size);
                }
                else
                    throw new ProtocolViolationException("Invalid reply to file transfer acceptance");
            }

            IFTPStreamPlugin streamPlugin = null;

            foreach (IFTPStreamPlugin p in _streamPlugins)
            {
                if (p.CompressionString != null)
                {
                    if (p.CanDecompress && r.Arguments[0].ToUpper() == p.CompressionString)
                    {
                        streamPlugin = p;
                        break;
                    }
                }
            }

            if (streamPlugin == null)
            {
                _sw.WriteLine("NACK");
                direct = false;
                goto nextOffer;
            }

            FileStream file = new FileStream(outputFile, FileMode.Create);

            if (!streamPlugin.Decompress(_ns, file, bufferSize, _lastFileCode, _lastTotalBytes)) // if cancelled
            {
                this.Disconnect();
                this.Connect(_lastServer, _lastPort);
            }
        }

        /// <summary>
        /// Forces the server to send a test file
        /// </summary>
        public void DoTest()
        {
            _sw.WriteLine("REQUESTTEST");
            IFTPReply reply;
            while ((reply = _sr.ReadReply()).Name != "TEST") ;
            long.TryParse(reply._repArgs.Last(), out _lastTestSize);
            _testStart = DateTime.Now;
            _sw.WriteLine("ACK");
            byte[] buffer = new byte[4096];
            for (long i = 0; i < _lastTestSize; i += buffer.Length)
            {
                _ns.Read(buffer, 0, buffer.Length);
            }
            SendTestTime();
        }

        /// <summary>
        /// Accepts a file offer, downloads the file completely without saving and determines the time of transfer.
        /// </summary>
        /// <param name="size">The size to download</param>
        internal void RunDownloadTest(long size)
        {
            ExceptionOnUninitialized();
            _sw.WriteLine("ACK");

            _testStart = DateTime.Now;

            byte[] buffer = new byte[4096]; // TODO [IFTPClient.RunDownloadTest] Implement buffer length as optional argument
            for (long s = 0; s < size; s += buffer.Length)
            {
                _ns.Read(buffer, 0, buffer.Length);
            }

            SendTestTime();
        }

        /// <summary>
        /// Allows a plugin (via event) to tell the client the current file transfer's progress.
        /// </summary>
        /// <param name="filecode">The code of the file transferring</param>
        /// <param name="bytesDecoded">The bytes decoded</param>
        /// <param name="bytesTotal">The bytes to be received totally</param>
        /// <param name="cancel">Will be True if the transfer should be cancelled</param>
        internal void StreamProgressReport(string filecode, long bytesDecoded, long bytesTotal, out bool cancel)
        {
            OnFileProgress(filecode, bytesDecoded, bytesTotal, out cancel);
        }

        /// <summary>
        /// Lets the client go from server with a notification.
        /// </summary>
        public void Quit()
        {
            _sw.WriteLine("QUIT");
        }

        /// <summary>
        /// Lets the client go from server with a reason.
        /// </summary>
        /// <param name="reason">The reason of quitting connection.</param>
        public void Quit(string reason)
        {
            _sw.WriteLine("QUIT \"" + reason + "\"");
        }

        #endregion

        #region Events
        public event EventHandler PassiveEnabled;
        public event EventHandler PassiveDisabled;
        public event EventHandler PortRangeChanged;
        public event EventHandler Connected;
        public event EventHandler Disconnected;
        public event EventHandler Initialized;
        public event FileProgressDelegate FileProgress;
        public event HandleStreamDelegate HandleStream;

        void IFTPClient_InternalPluginLoaded(IFTPStreamPlugin plugin)
        {
            plugin.ReportProgressEvent += new FileProgressDelegate(StreamProgressReport);
        }
        #endregion

        #region Event wrappers
        protected void OnFileProgress(string filecode, long bytesDecoded, long bytesTotal, out bool cancel)
        {
            if (FileProgress != null)
                FileProgress(filecode, bytesDecoded, bytesTotal, out cancel);
            else
                cancel = false;
        }
        protected void OnPassiveEnabled()
        {
            if (PassiveEnabled != null)
                PassiveEnabled(this, new EventArgs());
        }
        protected void OnPassiveDisabled()
        {
            if (PassiveDisabled != null)
                PassiveDisabled(this, new EventArgs());
        }
        protected void OnPortRangeChanged()
        {
            if (PortRangeChanged != null)
                PortRangeChanged(this, new EventArgs());
        }
        protected void OnConnected()
        {
            if (Connected != null)
                Connected(this, new EventArgs());
        }
        protected void OnDisconnected()
        {
            if (Disconnected != null)
                Disconnected(this, new EventArgs());
        }
        protected void OnInitialized()
        {
            if (Initialized != null)
                Initialized(this, new EventArgs());
        }
        #endregion

        #region Constructors

        /// <summary>
        /// The standard constructor. Initializes the version.
        /// </summary>
        public IFTPClient()
        {
            __CONSTRUCT();
        }

        private void __CONSTRUCT()
        {
            _version = Assembly.GetExecutingAssembly().GetName().Version;

            this.InternalPluginLoaded += new InternalPluginLoadedHandler(IFTPClient_InternalPluginLoaded);
        }

        #endregion

        #region Exception generators
        internal void ExceptionOnMissingFileRequest()
        {
            if (!_fileRequested)
                throw new FileRequestMissingException();
        }

        internal void ExceptionOnMissingFileInfoRequest()
        {
            if (!_fileInfoRequested)
                throw new FileInfoRequestMissingException();
        }

        internal void ExceptionOnInvalidOutputFileName()
        {
            if (outputFile.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
                throw new InvalidFilenameException();
        }

        internal void ExceptionOnInvalidPortRange(int[] ports)
        {
            ExceptionOnInvalidPortRange(ports[0], ports[1]);
        }

        internal void ExceptionOnInvalidPortRange(int bport, int eport)
        {
            if (bport < 1 || bport > 0xFFFF)
                throw new InvalidPortRangeException();
        }

        internal void ExceptionOnDisconnected()
        {
            if (!IsConnected)
                // throw new SocketException(10057);
                throw new NoConnectionException();
        }

        internal void ExceptionOnUninitialized()
        {
            ExceptionOnDisconnected();
            if (!IsInitialized)
                throw new ConnectionUninitializedException();
        }

        internal void ExceptionOnInitialized()
        {
            if (IsInitialized)
                throw new ConnectionInitializedException();
        }
        #endregion
    }
}
